React 入門 7 - Performance: memo, useMemo & useCallback


Posted by urlun0404 on 2022-12-24

很久之前有針對 useMemouseCallback,還有 memo 寫過學習筆記,這篇入門就是統整React的優化(optimization)概念,一起介紹跟優化相關的hooks和高階component(hidher-order component, HOC)

不過在此之前有兩個很重要的概念要記:

  1. 元件(component)會在props或state改變的時候重新渲染(re-render)
  2. 一般而言沒用任何手段處理,如果父元件(parent component)重新渲染,無論子元件(child component)的props或state有無改變,其子元件也會跟著父元件一起重新渲染。



memo

React在使用function components,有三個常見跟效能優化相關、在不同時機使用的技巧,分別是 memouseMemouseCallback,先來介紹 memo

"memo" 這個詞來自於memoization,節錄英文版維基百科的解釋:

memoization is an optimization technique used primarily to speed up computer programs by storing the results of expensive function calls and returning the cached result when the same inputs occur again.

大意是指memoization是電腦科學用來暫存函式運算結果的一種效能優化技巧,這篇不會多談memoization,還是回到React的 memo

memo是一種高階component(hidher-order component, HOC),也就是會接收component並回傳新的component,而memo的作用是「儲存component減少不必要的渲染」。

先簡要說明React的渲染機制是「只要state、props或context改變導致parent component重新渲染,這個component的child components以及子子孫孫components都會跟著一起渲染」

假設很不幸地某個component的child components包了好幾個child components(也就是有很多子子孫孫),可想而知,當傳入child components的props有無改變,子子孫孫們都會跟著最上層的components一起重新渲染,子子孫孫們的重新渲染正是所謂「不必要的渲染」,因為這些子子孫孫沒有必要一起改變,而大量的components一起重新渲染的結果可能會使得一個程式耗費非常多的時間去載入畫面。

為了避免這個狀況,可以用 memo 將有好幾層子子孫孫components的最上層component包起來,接著React會透過"淺比較",判定傳入components的props有無改變(包含記憶體位址是否有改變),只要props的值或變數記憶體位址沒有改變,這個component的子子孫孫components就不會跟著最上層component重新渲染。

這裡附上live demo和程式碼,範例用了三個state,分別是 isChildisAnotherChildisMemoChild,而Button只能改變 isChild

可以注意當點擊按鈕時改變 isChild 時,雖然傳入AnotherChild的props isAnotherChild 沒有改變但會顯示"Another Child loads",而傳入MemoChild的props isMemoChild 雖然也沒有改變,但有用memo包住,所以不會顯示"Memo Child loads"的訊息。

// app.js
export default function App(){
    const [isChild, setIsChild] = useState(false);
    const [isMemoChild, setIsMemoChild] = useState(false);
    return (
        <>
            <MemoChild isMemoChild={isMemoChild} />
            <Child isChild={isChild} />
            <Button onClick={() => setIsChild((prevMe) => !prevMe)}>
            Click and Show Child!
            </Button>
        </>
    );

}

// Child.js
export default function Child(props) {
  console.log("Child loads");
  return <p>{props.isChild && `It's Child`}</p>;
};

// AnotherChild.js
export default function AnotherChild(props) {
  console.log("Another Child loads");
  return <p>{props.isAnotherChild && `It's Another Child`}</p>;
};

// MemoChild.js
export default React.memo(function MemoChild(props) {
  console.log("Memo Child loads");
  return <p>{props.isMemoChild && `It's Memo Child`}</p>;
});



useMemo

memo 是一個HOC,而 useMemo 則是一個hook,主要是用來保存參考型變數(物件、陣列和函式)或是函式運算完的回傳值,使用方式如範例的運算函式 expensiveFunc 要放在 useMemo 內的一個callback函式中,並將任何與這個運算函式有關的引數或值都放在dependencies ─ 也就是 useMemo 的陣列中。

const memoizedValue = useMemo(() => expensiveFunc(input), [input])

假設 expensiveFunc 是一個運算成本很高的函式,例如每次呼叫這個函式都會先跑一個無用的迴圈10000000次,再判斷引數input並回傳相應的值:

function expensiveFunc(input){

    for(let i = 0; i < 100000000; ++i){
        ;   // do nothing
    }

    switch(input){
        case "a":
            return "a";
        case "b":
            return "b";
        default: 
            return "n/a";
    }

}

如果不用useMemo包起來:

const memoizedValue = expensiveFunc(input);

那麼每次有這個 memoizedValue 變數的component重新渲染的時候,都會重跑一次 expensiveFunc 並儲存值在 memoizedValue 之中,每重跑一次 expensiveFunc 就會耗費很多時間在跑100000000次的迴圈。

若有使用 useMemo,input值不變,則因為運算函式的回傳值不變 useMemo 會確保 expensiveFunc 不用重新運算。

前面解釋 memo 有提到如果傳入component的props值或變數記憶體位址不變,memo 就會保存同樣一個component。

為何會提到變數的記憶體位址?

Function component的本質是函式(function),

function child(){
    return [1, 2, 3];
}

如果熟悉JavaScript語法,可以從JavaScript的角度去想,如果沒有刻意儲存child函式回傳的 [1, 2, 3] 陣列,每次重新呼叫函式回傳的 [1, 2, 3] 都會要一塊在不同記憶體位址的陣列存放 1, 2, 3 三個值。

同理,React的function component每次重新渲染時,任何參考型(reference type)變數都會重新宣告在新的記憶體為止:

export default function Child(){
    const arr = [1, 2, 3];

    return (
    <ul>
        {
            arr.forEach(num => <li>{num}</li>)
        }
    </ul>)
}

如範例中的陣列 arr ,即使每次重新渲染的陣列值都是 [1, 2, 3],看起來長得一樣,但因為會重新宣告一個陣列,也就是重新要另外一塊記憶體去保存陣列的值,另一塊記憶體的位址就會和渲染前的物件記憶體位址不同;而 memo 是作"淺比較",只要物件記憶體位址就不等同於是原來同一個物件。

為了保存同一個陣列,這時可以用 useMemo 去保存這個陣列:

const arr = useMemo(() => [1, 2, 3], []);

這邊再舉另一個例子:

const sum = useMemo(() => add(a, b), [a, b])

useMemo 第二個引數 [a, b]useEffect 的第二個引數一樣是dependencies,如果useMemo 第一個引數callback函式中有用到任何dependency,只有當有一dependency不一樣時,也就是範例中a或b其一的值不同時,才會讓第一個引數callback函式中 add(a, b) 運算後回傳值的記憶體位址不同,否則函式回傳的都會是同一塊記憶體位址且是同樣的值。

useMemo 是保存(1) 一般物件、陣列的記憶體位址或者(2)函式運算的回傳值,如果要保存的是函式本身,則是用 useCallback hook。



useCallback

useMemo 事實上也可以保存函式(function),例如:

const func = useMemo(() => {
    return function expensiveFunc(){}
}, []);

useCallback 算是一種語法糖,上面範例可以用改寫成:

const func = useCallback(() => {
    function expensiveFunc(){}
}, []);

雖然 memouseMemouseCallback 都是用來改善效能,但事實上要判斷是否為同樣的變數值、物件(一般物件、陣列、函式)也要花費成本去判斷,所以 memouseMemouseCallback 不能毫無限制地使用,要在有必要時適當運用,例如:

  1. 保存成本運算極高的函式或函式運算值
  2. 保存參考型變數(reference)

如下程式碼如果 useEffect 的 dependencies - fetchData 不保存記憶體位址,則每次重新轉譯一次function component就會產生一個內容相同、記憶體位址不同的 fetchData 並且執行 useEffect 裡的 fetchData

function App(){

    const fetchData = async () => {
        const [data1, data2] = await Promise.all([fetchData1(), fetchData2()]);
        setCurrData({
            ...data1,
            ...data2
        });
    }

    useEffect(() => {
        fetchData();
    }, [fetchData]);
}



總結三個東西的用途:

  • memo:儲存有好幾層children或descendants的component,避免component的state或props沒變卻會跟著parent component一起重新渲染;
  • useMemo:主要是儲存一般物件或陣列,可適時保存同個記憶體位址的參考型變數或是呼叫/運算成本較高的函式回傳值;
  • useCallback:主要是儲存函式
    因為值相同、記憶體位址不同的參考型變數在傳遞時會被當成不同的state或props,因此後兩者亦可搭配 memo 去保存記憶體位址相同的參考型變數,避免因記憶體位址不同被當成state或props而產生不必要的渲染。



最後推薦幾篇英文和中文文章:



後記

實際上React本身已經有很好的優化機制,使用 memouseMemouseCallback 除了會有比較上的成本,也需要額外的記憶體空間來記住要保存的值,所以不能濫用 memouseMemouseCallback

因此有時候可根據需求將states放在不同職責的child components,而不是都把所有states放在同一個(parent) component,避免任一state改變時會重新渲染不必要的元素,也可減少使用 memouseMemouseCallback 的次數。

這裡也有一篇文章 除了摘錄 memouseMemo 的重點,也有提到如何拆開元件去避免過度濫用 memouseMemo


References
[Note] React - Hooks: useMemo
[Note] React: React.memo
[Note] React - Hooks: useCallback
Higher-Order Components @React Docs
Memoization @wiki
JavaScript Memoization @GeeksForGeeks
What is Memoization?
useMemo @React Docs
useMemo @React Docs BETA
React.memo() vs. useMemo(): Major differences and use cases
React.memo and useMemo - What's the Difference?
Deeper Dive Into React.memo
Deeper Dive Into React useMemo
Understanding useMemo and useCallback
How to useMemo and useCallback: you can remove most of them


#frontend #React







Related Posts

原型與繼承(2) - 原型鍊

原型與繼承(2) - 原型鍊

Spread Operator 與 Rest Parameters 與 Default Parameters

Spread Operator 與 Rest Parameters 與 Default Parameters

JS Advanced --Hoisting

JS Advanced --Hoisting


Comments